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 myapporpm2 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
OPTIONSrequests are handled - If using credentials (cookies),
Access-Control-Allow-Origincannot 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 fromwindow.location.origin
App Crashes on Startup
- Missing environment variables — check
journalctl -u myappfor the error - Port already in use —
sudo ss -tlnp | grep :3000 - Missing dependencies — did you run
npm ciorpip 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 myapporsudo systemctl restart myapp
Prevention Checklist
Before every deployment, verify the following:
- All configuration comes from environment variables (no hardcoded URLs, ports, or secrets)
-
.envfiles are in.gitignore - App binds to
127.0.0.1, not0.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 — €49100% money-back guarantee