← All Articles
Last updated: 2026-03-30

Works on Localhost But Not Live? How to Deploy Your App to Production

Deploy a Node.js or Python app to a VPS. Nginx reverse proxy, SSL, environment variables, systemd — complete guide.

TL;DR

Your app works on localhost:3000 but breaks in production? The usual suspects are: hardcoded ports and hosts, missing environment variables, no HTTPS, CORS misconfiguration, and missing process managers. This guide walks you through every step — from preparing your code to a fully automated deployment with Nginx, SSL, and CI/CD.

Prerequisites

  • A VPS (Ubuntu 22.04/24.04) with root or sudo access
  • A registered domain pointing to your server's IP (A record)
  • Your app running successfully on localhost
  • Basic familiarity with SSH and the Linux command line
  • A GitHub repository containing your project

1. Localhost vs. Production — What Changes?

Understanding why things break is half the battle. Here are the key differences between your local development environment and a production server:

Environment Variables

Locally, you might have a .env file or hardcoded values. In production, these must be set explicitly and securely — never committed to version control.

# Local .env (DO NOT deploy this)
DATABASE_URL=mongodb://localhost:27017/myapp
API_KEY=dev-key-12345
NODE_ENV=development

# Production — set via systemd, pm2 ecosystem, or shell profile
DATABASE_URL=mongodb://db-server:27017/myapp?authSource=admin
API_KEY=prod-key-real-secret
NODE_ENV=production

Ports & Binding

Locally your app binds to localhost:3000. In production, it should bind to 127.0.0.1 (not 0.0.0.0 on the public port) and sit behind a reverse proxy on ports 80/443.

CORS (Cross-Origin Resource Sharing)

On localhost, your frontend and backend often share the same origin. In production, your API might live on api.example.com while your frontend is on example.com.

// Express.js CORS — don't use '*' in production
const cors = require('cors');
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || 'https://example.com',
  credentials: true
}));

HTTPS

Browsers enforce strict security policies for production origins. Features like navigator.geolocation, service workers, and secure cookies require HTTPS. There is no workaround — you need a valid SSL certificate.

Process Lifecycle

Locally, you press Ctrl+C to stop your app. In production, your app must survive server reboots, crashes, and memory leaks. This requires a process manager.

2. Preparing Your App for Production

Externalize All Configuration

Every value that differs between environments must come from environment variables:

# Node.js
const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
  console.error('DATABASE_URL is not set. Exiting.');
  process.exit(1);
}

# Python (Flask/FastAPI)
import os
PORT = int(os.environ.get('PORT', 8000))
DATABASE_URL = os.environ['DATABASE_URL']  # Will raise KeyError if missing

Add a Health Check Endpoint

// Express.js
app.get('/health', (req, res) => {
  res.json({ status: 'ok', uptime: process.uptime() });
});

# FastAPI
@app.get('/health')
def health():
    return {'status': 'ok'}

Create a Production Start Script

In your package.json (Node.js):

{
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  }
}

3. Deploying to a VPS

Initial Server Setup

# SSH into your server
ssh root@your-server-ip

# Create a deploy user (don't run apps as root)
adduser deploy
usermod -aG sudo deploy

# Set up SSH key auth for the deploy user
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh

Install Runtime Dependencies

# Node.js (via NodeSource)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs

# Python
sudo apt-get install -y python3 python3-pip python3-venv

# Install Nginx
sudo apt-get install -y nginx

Option A: Node.js with PM2

# Install PM2 globally
sudo npm install -g pm2

# Clone and set up your app
cd /home/deploy
git clone https://github.com/youruser/yourapp.git
cd yourapp
npm ci --production

# Create PM2 ecosystem file
cat > ecosystem.config.js << 'EOF'
module.exports = {
  apps: [{
    name: 'myapp',
    script: 'src/server.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 3000,
      DATABASE_URL: 'mongodb://localhost:27017/myapp'
    },
    max_memory_restart: '500M',
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z'
  }]
};
EOF

# Start the app
pm2 start ecosystem.config.js

# Save the process list and enable startup on boot
pm2 save
pm2 startup systemd -u deploy --hp /home/deploy

Option B: Node.js with systemd

# Create a systemd service file
sudo cat > /etc/systemd/system/myapp.service << 'EOF'
[Unit]
Description=My Node.js App
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/yourapp
ExecStart=/usr/bin/node src/server.js
Restart=on-failure
RestartSec=10
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/home/deploy/yourapp/.env.production
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

[Install]
WantedBy=multi-user.target
EOF

# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
sudo systemctl status myapp

Option C: Python with Gunicorn and systemd

# Set up virtual environment
cd /home/deploy/yourapp
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install gunicorn

# Create systemd service
sudo cat > /etc/systemd/system/myapp.service << 'EOF'
[Unit]
Description=My Python App (Gunicorn)
After=network.target

[Service]
Type=notify
User=deploy
WorkingDirectory=/home/deploy/yourapp
ExecStart=/home/deploy/yourapp/venv/bin/gunicorn \
    --workers 3 \
    --bind 127.0.0.1:8000 \
    --access-logfile /var/log/myapp/access.log \
    --error-logfile /var/log/myapp/error.log \
    app:app
Restart=on-failure
RestartSec=5
EnvironmentFile=/home/deploy/yourapp/.env.production

[Install]
WantedBy=multi-user.target
EOF

# Create log directory and start
sudo mkdir -p /var/log/myapp
sudo chown deploy:deploy /var/log/myapp
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp

4. Nginx Reverse Proxy Configuration

Nginx sits in front of your application, handling SSL termination, static files, and load balancing.

# /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name example.com www.example.com;

    # Redirect all HTTP to HTTPS (after SSL is set up)
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    # SSL certificates (managed by Certbot — see next section)
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Proxy to your app
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 90s;
    }

    # Serve static files directly (optional, for frameworks that have a build step)
    location /static/ {
        alias /home/deploy/yourapp/public/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Health check bypass (no logging)
    location /health {
        proxy_pass http://127.0.0.1:3000;
        access_log off;
    }
}
# Enable the site and test
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Important: If you get a 502 Bad Gateway, your app is not running on the expected port. Check with curl -v http://127.0.0.1:3000/health from the server.

5. SSL with Let's Encrypt

# Install Certbot
sudo apt-get install -y certbot python3-certbot-nginx

# Obtain certificate (Certbot auto-configures Nginx)
sudo certbot --nginx -d example.com -d www.example.com

# Verify auto-renewal
sudo certbot renew --dry-run

# Certbot installs a systemd timer for auto-renewal. Check it:
sudo systemctl status certbot.timer

Certbot will modify your Nginx config to add SSL directives. Certificates renew automatically every 60-90 days.

Tip: If your DNS is not yet pointing to the server, Certbot will fail. Verify with:

dig +short example.com
# Should return your server's IP

6. Basic CI/CD with GitHub Actions

Automate deployments so you never have to SSH in and run git pull manually.

Set Up SSH Deploy Key

# On your local machine, generate a deploy key
ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N ""

# Add the public key to the server
cat ~/.ssh/deploy_key.pub | ssh deploy@your-server-ip \
  "cat >> ~/.ssh/authorized_keys"

# Add the private key as a GitHub secret:
# Repository > Settings > Secrets > DEPLOY_SSH_KEY

GitHub Actions Workflow

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: deploy
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /home/deploy/yourapp
            git pull origin main
            npm ci --production
            pm2 restart myapp --update-env
            echo "Deployed at $(date)"

Adding Tests Before Deploy

# .github/workflows/deploy.yml (extended)
name: Test & Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: deploy
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /home/deploy/yourapp
            git pull origin main
            npm ci --production
            pm2 restart myapp --update-env

7. Checking Logs & Debugging

When something breaks in production, logs are your first stop.

# PM2 logs
pm2 logs myapp --lines 100
pm2 logs myapp --err           # Only error output

# systemd journal
sudo journalctl -u myapp -f              # Follow live
sudo journalctl -u myapp --since "1 hour ago"
sudo journalctl -u myapp --since "2025-01-15 14:00"

# Nginx logs
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log

# Gunicorn logs
sudo tail -f /var/log/myapp/error.log

# Check which process is listening on a port
sudo ss -tlnp | grep :3000

# Test your app directly (bypassing Nginx)
curl -v http://127.0.0.1:3000/health

# Test through Nginx
curl -v https://example.com/health

Troubleshooting

502 Bad Gateway

Nginx cannot reach your app. Check:

  • Is the app running? sudo systemctl status myapp or pm2 status
  • Is it listening on the right port? sudo ss -tlnp | grep :3000
  • Is the port in the Nginx config correct?

CORS Errors in Browser Console

Your API does not include the correct Access-Control-Allow-Origin header.

  • Check that your CORS middleware uses the production frontend URL, not localhost
  • Ensure preflight OPTIONS requests are handled
  • If using credentials (cookies), Access-Control-Allow-Origin cannot be *

Mixed Content Warnings

Your page loads over HTTPS but makes requests to HTTP URLs.

  • Ensure all API URLs use https:// in your frontend config
  • Use relative URLs (/api/data) or build the URL from window.location.origin

App Crashes on Startup

  • Missing environment variables — check journalctl -u myapp for the error
  • Port already in use — sudo ss -tlnp | grep :3000
  • Missing dependencies — did you run npm ci or pip install -r requirements.txt?

SSL Certificate Not Working

  • DNS not pointing to server — dig +short example.com
  • Port 80 blocked by firewall — sudo ufw allow 80
  • Certbot timer not running — sudo systemctl status certbot.timer

Changes Not Showing After Deploy

  • Browser cache — hard refresh with Ctrl+Shift+R
  • CDN or Nginx cache — clear or disable during debugging
  • App not restarted — pm2 restart myapp or sudo systemctl restart myapp

Prevention Checklist

Before every deployment, verify the following:

  • All configuration comes from environment variables (no hardcoded URLs, ports, or secrets)
  • .env files are in .gitignore
  • App binds to 127.0.0.1, not 0.0.0.0 (Nginx handles public traffic)
  • CORS is configured with the exact production origin
  • A process manager (PM2/systemd) is configured with auto-restart
  • Nginx reverse proxy is tested with nginx -t
  • SSL certificate is valid and auto-renewing
  • Health check endpoint exists and is monitored
  • CI/CD pipeline runs tests before deploying
  • Firewall only exposes ports 22, 80, and 443
  • Log rotation is configured to prevent disk full
  • You have tested a rollback — can you revert quickly if something breaks?

Need Expert Help?

Still stuck? I deploy it live via screen share. €49, 30 min.

Book Now — €49

100% money-back guarantee

HR

Harald Roessler

Infrastructure Engineer with 20+ years experience. Founder of DSNCON GmbH.