Deploy a Node.js App to AWS EC2 With PM2, Nginx, and SSL
Step-by-step guide to deploying a Node.js application on AWS EC2 with PM2 process management, Nginx reverse proxy, free SSL certificates, and zero-downtime deployments.
DL
Shantanu Kumar
Chief Solutions Architect
March 12, 2026
20 min read
Updated March 2026
Deploying a Node.js application to production is where most tutorials fall short. They show you how to run node server.js on an EC2 instance and call it done. But production deployment means your app survives server restarts, handles traffic spikes, serves over HTTPS, and can be updated without dropping active connections.
This guide covers the full deployment pipeline we use at Dude Lemon for client projects on AWS: provisioning an EC2 instance, configuring PM2 for process management and clustering, setting up Nginx as a reverse proxy, installing free SSL certificates with Let's Encrypt, and building a deployment script that achieves zero-downtime updates.
Production is not a server running your code. Production is a system that keeps running your code when everything else goes wrong.
1) Provision and secure your EC2 instance
Start with an Ubuntu 24.04 LTS instance. For most Node.js applications, a t3.small (2 vCPU, 2 GB RAM) is sufficient for initial production traffic. You can always scale up later. When creating the instance, configure the security group to allow SSH (port 22), HTTP (port 80), and HTTPS (port 443).
bashInitial Server Setup
1# Connect to your instance
2ssh-i~/.ssh/your-key.pemubuntu@your-instance-ip
3
4# Update system packages
5sudoaptupdate&&sudoaptupgrade-y
6
7# Install essential tools
8sudoaptinstall-ycurlgitbuild-essentialufw
9
10# Configure firewall
11sudoufwallowOpenSSH
12sudoufwallow'NginxFull'
13sudoufwenable
14sudoufwstatus
15
16# Set timezone
17sudotimedatectlset-timezoneAmerica/Los_Angeles
The firewall configuration is critical. UFW (Uncomplicated Firewall) blocks all incoming traffic except SSH and Nginx ports. Your Node.js application runs on a high port (like 3000) that is only accessible through the Nginx reverse proxy — never directly from the internet. This prevents direct attacks against your application server.
2) Install Node.js with NVM
Use NVM (Node Version Manager) instead of the system package manager. NVM lets you switch Node versions without sudo, run multiple versions side by side, and update without breaking system dependencies. This matters when you maintain multiple applications on the same server.
Create a dedicated directory structure for your application. Keeping application code, logs, and configuration in predictable locations makes debugging and automation much easier. Use environment variables for all configuration — never hardcode database URLs, API keys, or secrets.
The npm ci command is different from npm install. It does a clean install from the lockfile, which is faster, deterministic, and catches dependency drift between local development and production. The chmod 600 on the .env file ensures only the file owner can read it — other system users cannot access your secrets.
4) Configure PM2 for production process management
PM2 is a process manager that keeps your Node.js application running. It handles automatic restarts on crash, log management, cluster mode for multi-core utilization, and startup scripts that survive server reboots. Without PM2, a single uncaught exception kills your application permanently.
javascriptecosystem.config.cjs
1module.exports={
2apps:[
3{
4name:'my-api',
5script:'./server.js',
6instances:'max',// Use all available CPU cores
7exec_mode:'cluster',// Enable cluster mode
8max_memory_restart:'512M',
9
10 // Environment variables
11env:{
12NODE_ENV:'production',
13PORT:3000,
14},
15
16 // Logging
17log_date_format:'YYYY-MM-DDHH:mm:ssZ',
18error_file:'/var/www/myapp/logs/error.log',
19out_file:'/var/www/myapp/logs/output.log',
20merge_logs:true,
21log_type:'json',
22
23 // Restart behavior
24max_restarts:10,
25min_uptime:'10s',
26restart_delay:4000,
27autorestart:true,
28
29 // Graceful shutdown
30kill_timeout:5000,
31listen_timeout:10000,
32shutdown_with_message:true,
33
34 // Watch (disabled in production)
35watch:false,
36},
37],
38}
Cluster mode is the most important PM2 feature for production. Node.js runs on a single thread by default, which means a quad-core server only uses 25% of its CPU. Cluster mode spawns one process per core and distributes incoming connections across all of them. On a t3.small with 2 cores, you double your throughput instantly.
bashPM2 Commands
1# Create logs directory
2mkdir-p/var/www/myapp/logs
3
4# Start application with PM2
5cd/var/www/myapp/app
6pm2startecosystem.config.cjs
7
8# Verify processes are running
9pm2status
10
11# View real-time logs
12pm2logsmy-api--lines50
13
14# Monitor CPU and memory usage
15pm2monit
16
17# Configure PM2 to start on boot
18pm2startup
19# Run the command PM2 outputs (sudo env PATH=...)
20pm2save
21
22# Zero-downtime reload (graceful)
23pm2reloadmy-api
24
25# Hard restart (drops connections)
26pm2restartmy-api
The difference between pm2 reload and pm2 restart is critical. Reload performs a rolling restart — it starts new processes, waits for them to be ready, then kills old ones. Active connections are never dropped. Restart kills all processes immediately and starts new ones, which means every in-flight request gets a connection reset error. Always use reload for deployments.
5) Configure Nginx as a reverse proxy
Nginx sits in front of your Node.js application and handles SSL termination, static file serving, gzip compression, request buffering, and connection management. This offloads work from Node.js so your application code focuses on business logic, not HTTP plumbing.
The upstream block with keepalive 64 maintains persistent connections between Nginx and Node.js, avoiding the overhead of TCP handshakes on every request. The location block that denies access to dotfiles prevents attackers from accessing .env, .git, or other sensitive hidden files through the web server.
6) Install SSL certificates with Let's Encrypt
SSL is not optional. Modern browsers flag HTTP sites as insecure, search engines penalize them in rankings, and APIs that handle any user data must encrypt traffic. Let's Encrypt provides free, auto-renewing SSL certificates through Certbot.
Certbot modifies your Nginx configuration automatically. It adds SSL certificate paths, enables TLS 1.2 and 1.3, configures secure cipher suites, and adds an HTTP to HTTPS redirect. After running Certbot, your Nginx config will have a listen 443 ssl block with all the correct settings.
Certificates from Let's Encrypt expire every 90 days. The certbot package installs a systemd timer that runs renewal checks twice daily. As long as port 80 remains accessible for the ACME challenge, renewals happen automatically with zero manual intervention.
7) Automated deployment script
A deployment script turns a multi-step manual process into a single command. Good deployment scripts are idempotent (safe to run multiple times), provide clear output at each stage, and handle failures gracefully. This script pulls the latest code, installs dependencies, and performs a zero-downtime PM2 reload.
The set -euo pipefail at the top is essential. The -e flag stops execution on any error. The -u flag treats unset variables as errors. The -o pipefail flag catches errors in piped commands. Without these, a failed git pull would still attempt npm install and pm2 reload, potentially deploying broken code.
The health check after reload is your safety net. If the application fails to start properly, the script automatically rolls back to the previous commit and reloads. This turns what would be a 2 AM emergency into a failed deployment that leaves the previous working version running.
8) Log management and monitoring
Logs are your primary debugging tool in production. PM2 manages application logs, but you also need to monitor Nginx access logs, error logs, and system resources. Set up log rotation to prevent disk space exhaustion, and use structured logging for easier parsing.
bashLog Rotation Configuration
1# Install logrotate config for PM2 logs
2pm2installpm2-logrotate
3
4# Configure rotation settings
5pm2setpm2-logrotate:max_size50M
6pm2setpm2-logrotate:retain14
7pm2setpm2-logrotate:compresstrue
8pm2setpm2-logrotate:dateFormatYYYY-MM-DD_HH-mm
9pm2setpm2-logrotate:workerInterval30
10
11# View current PM2 log paths
12pm2logs--json|head-20
13
14# Monitor system resources
15htop#Interactiveprocessviewer
16df-h#Diskusage
17free-h#Memoryusage
18ss-tlnp#Activelisteningports
bashUseful monitoring commands
1# Check if your app is responding
2curl-shttp://localhost:3000/health | jq
3
4# Watch PM2 process status
5watchpm2status
6
7# Tail application errors
8pm2logsmy-api--err--lines100
9
10# Check Nginx access logs
11sudotail-f/var/log/nginx/access.log
12
13# Check Nginx error logs
14sudotail-f/var/log/nginx/error.log
15
16# Find processes using port 3000
17sudolsof-i:3000
9) Security hardening checklist
A deployed application is a target. Every server exposed to the internet receives automated attacks within minutes. These hardening steps are not optional — they are the minimum baseline for any production deployment.
Disable root SSH login — edit /etc/ssh/sshd_config and set PermitRootLogin no.
Use SSH key authentication only — set PasswordAuthentication no in sshd_config.
Keep packages updated — run sudo apt update && sudo apt upgrade weekly or enable unattended-upgrades.
Configure fail2ban to block brute-force SSH attempts automatically.
Never expose your Node.js port directly — always proxy through Nginx.
Set restrictive file permissions on .env files (chmod 600) and SSH keys (chmod 400).
Enable automatic security updates with unattended-upgrades package.
Use security groups in AWS to restrict inbound traffic to only necessary ports.
Rotate JWT secrets and database credentials periodically.
Monitor failed login attempts in /var/log/auth.log.
If your API serves any static assets or has cacheable responses, Nginx can serve them directly without touching Node.js. For full-stack deployments where Nginx also serves your frontend build, add proper cache headers to dramatically reduce server load and improve page load times.
nginxStatic file caching block (add to Nginx config)
20# Enable Nginx microcaching for API responses (optional)
21proxy_cache_path/tmp/nginx_cachelevels=1:2
22keys_zone=api_cache:10mmax_size=100m
23inactive=5muse_temp_path=off;
24
25location/api/public{
26proxy_passhttp://node_backend;
27proxy_cacheapi_cache;
28proxy_cache_valid20030s;
29proxy_cache_use_staleerrortimeoutupdating;
30add_headerX-Cache-Status$upstream_cache_status;
31}
11) Setting up CI/CD with GitHub Actions
Manually SSH-ing to run deploy.sh works, but it does not scale and it introduces human error. GitHub Actions can automate the entire pipeline: run tests on every push, and deploy automatically when code merges to main. Here is a workflow that handles both.
The test job spins up a real PostgreSQL database in a Docker container and runs your test suite against it. No mocks, no SQLite substitutes — tests hit the same database engine you use in production. The deploy job only runs after tests pass and only on pushes to main, preventing broken code from reaching production.
12) Complete deployment verification
After deploying, run through this verification checklist to confirm everything is working correctly. Catching problems immediately after deployment is infinitely cheaper than discovering them from user reports.
Here is how all the pieces fit together in the final architecture. Internet traffic hits your domain, which resolves to your EC2 instance public IP. Nginx listens on ports 80 and 443, terminates SSL, compresses responses, and proxies API requests to your Node.js processes on port 3000. PM2 manages multiple Node.js worker processes in cluster mode, distributing load across all CPU cores. Each worker connects to PostgreSQL through a connection pool. The deployment script orchestrates updates with zero downtime by performing rolling restarts through PM2.
This setup runs on a single EC2 instance, which keeps costs low for early-stage and mid-traffic applications. A t3.small instance costs approximately $15 per month. Combined with a free-tier RDS instance or a self-hosted PostgreSQL installation, you can run a production API for under $30 per month.
Vertical scaling — upgrade instance size (t3.small → t3.medium → t3.large) for more CPU and RAM. No architecture changes needed.
Horizontal scaling — add an Application Load Balancer (ALB) in front of multiple EC2 instances. PM2 cluster mode already handles per-instance concurrency.
Database scaling — migrate from self-hosted PostgreSQL to Amazon RDS for automated backups, failover, and read replicas.
CDN layer — add CloudFront in front of Nginx to cache static assets at edge locations worldwide, reducing latency for global users.
Container migration — when complexity justifies it, containerize with Docker and deploy to ECS or EKS for orchestrated scaling.
Common deployment problems and fixes
These are the deployment issues we encounter most frequently on client projects. Each one has a specific cause and a straightforward fix.
EACCES permission denied — your Node.js process is trying to bind to a port below 1024. Use a high port (3000+) and proxy through Nginx instead.
PM2 processes show errored status — check pm2 logs for the actual error. Usually a missing environment variable or failed database connection.
Nginx 502 Bad Gateway — Node.js is not running or is running on a different port than Nginx expects. Verify with curl localhost:3000/health.
SSL certificate renewal failing — port 80 must be accessible for Let's Encrypt HTTP challenge. Check security group and UFW rules.
deploy.sh fails on git pull — uncommitted changes on the server. The script uses git reset --hard to avoid this, but check for .env file conflicts.
High memory usage — Node.js memory leak or missing PM2 max_memory_restart. Set a memory limit so PM2 restarts workers before they exhaust RAM.
Connection timeouts after deploy — the old process was killed before the new one was ready. Use pm2 reload (not restart) and ensure listen_timeout is set.
Deployment is not a one-time task — it is an operational capability that improves over time. Start with this guide as your foundation, then iterate: add monitoring dashboards, set up alerting, automate database backups, and build runbooks for common incidents. Every improvement reduces the time and stress of shipping code to production.
At Dude Lemon, we handle production deployments for clients who need their applications running reliably without building an internal DevOps team. If you need help deploying your Node.js application or setting up a production infrastructure on AWS, our cloud engineering team is available for a free architecture review.
The best deployment pipeline is the one your team actually trusts enough to use on a Friday afternoon.
Need help building this?
Let our team build it for you.
Dude Lemon builds production-grade web apps, APIs, and cloud infrastructure. Get a free consultation and project proposal within 48 hours.