Skip to main content
All posts
  • Node.js
  • AWS EC2
  • DevOps
  • GitHub Actions

Deploy a Node.js App to AWS EC2 with Nginx, PM2 and GitHub Actions

A production-ready setup for Node.js on a single EC2 instance — PM2 cluster mode, Nginx reverse proxy with free HTTPS, and push-to-deploy with GitHub Actions and zero-downtime reloads.

SK
Santosh Kumar

· 5 min read

On this page

Platforms like Vercel and Render are great, but plenty of real projects (especially government and enterprise work, where I've spent a lot of my time) run on plain EC2 instances. You get full control, predictable cost and no platform limits. You also get every operational detail to handle yourself.

This is the setup I reach for when a Node.js API (Express or Fastify) needs to live on a single EC2 server: PM2 keeps it running, Nginx sits in front, Certbot handles HTTPS, and GitHub Actions deploys on every push to main.

The architecture#

Architecture
Internet ──443──▶ Nginx (TLS, reverse proxy)
                    │
                    └──▶ 127.0.0.1:3000 ──▶ PM2 (cluster: one Node process per CPU)

Only Nginx faces the internet. Node listens on localhost, so nothing can reach it except through Nginx.

1. Launch and lock down the instance#

Launch an Ubuntu LTS instance and set the security group inbound rules:

Port Source Why
22 (SSH) Your IP (see the CI note below) Admin access
80 (HTTP) Anywhere Certbot challenge + redirect to HTTPS
443 (HTTPS) Anywhere Your app

Do not open port 3000. Attach an Elastic IP so the address survives restarts, and point your domain's A record (say api.example.com) at it.

2. Install Node.js and PM2#

I install Node from NodeSource rather than nvm on servers. nvm only loads in interactive shells, which causes confusing "command not found: node" errors when PM2 or CI connect over non-interactive SSH.

Terminal
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs nginx
sudo npm install -g pm2

Clone your app (use a read-only deploy key for private repos), install and build:

Terminal
git clone git@github.com:you/your-api.git ~/app
cd ~/app
npm ci
npm run build

Keep secrets in a .env file on the server (loaded by your app, e.g. with dotenv) and never commit them.

3. Run it with PM2 in cluster mode#

PM2 restarts your app if it crashes, starts it on boot, and in cluster mode runs one process per CPU core so you use the whole machine.

ecosystem.config.js
// Name it ecosystem.config.cjs if your package.json has "type": "module"
module.exports = {
  apps: [
    {
      name: "api",
      script: "dist/server.js",
      instances: "max",      // one per CPU core
      exec_mode: "cluster",
      max_memory_restart: "500M",
      env: {
        NODE_ENV: "production",
        PORT: 3000,
      },
    },
  ],
};
Terminal
pm2 start ecosystem.config.js
pm2 save                 # remember the process list
pm2 startup systemd      # prints a sudo command. Run it to start PM2 on boot.

Useful day-to-day commands: pm2 status, pm2 logs api, pm2 monit. Install log rotation once with pm2 install pm2-logrotate so logs don't fill the disk.

Cluster mode has one rule: no in-memory state shared between requests (sessions, caches, rate limit counters). Each process has its own memory, so keep that state in Redis or your database.

4. Put Nginx in front#

Create /etc/nginx/sites-available/api:

/etc/nginx/sites-available/api
server {
    listen 80;
    server_name api.example.com;

    client_max_body_size 10m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;      # WebSocket support
        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;
    }
}

Enable it and reload:

Terminal
sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

Then tell your app it's behind a proxy, or req.ip will always be 127.0.0.1 and anything IP-based (logging, rate limiting) breaks:

JavaScript
app.set("trust proxy", 1); // Express. In Fastify: Fastify({ trustProxy: true })

5. Free HTTPS with Certbot#

Terminal
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com

Certbot gets a Let's Encrypt certificate, rewrites the Nginx config to serve HTTPS and redirect HTTP, and installs a timer that renews automatically. Check renewal works with sudo certbot renew --dry-run.

6. Push-to-deploy with GitHub Actions#

The workflow: run tests on every push to main, and only if they pass, SSH into the server, pull, build and reload PM2.

Add three repository secrets under Settings → Secrets and variables → Actions: EC2_HOST, EC2_USER (e.g. ubuntu) and EC2_SSH_KEY (a private key whose public half is in the server's ~/.ssh/authorized_keys; generate a dedicated one for CI).

.github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

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

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USER }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            set -e
            cd ~/app
            git pull --ff-only origin main
            npm ci
            npm run build
            pm2 reload ecosystem.config.js --update-env

set -e stops the deploy at the first failing command, so a broken build never gets reloaded. --ff-only refuses to deploy if someone edited files directly on the server.

Zero-downtime reloads#

pm2 reload (not restart) replaces cluster workers one at a time, so there's always a process serving traffic. For it to be truly seamless, finish in-flight requests on shutdown:

JavaScript
process.on("SIGINT", () => {
  server.close(() => process.exit(0)); // stop accepting, finish open requests
});

A note on SSH and CI#

GitHub-hosted runners use changing IP addresses, so the deploy job needs port 22 reachable from outside your own IP. If that's a concern, the main options are: SSH with key-only auth (disable password login in /etc/ssh/sshd_config) plus fail2ban; a self-hosted runner inside your VPC; or AWS Systems Manager instead of SSH.

Checklist#

  • ✅ Security group exposes only 22 (restricted), 80 and 443, never the app port
  • ✅ Node from NodeSource, PM2 in cluster mode, pm2 startup + pm2 save
  • ✅ Nginx reverse proxy with forwarded headers, and trust proxy in the app
  • ✅ HTTPS via Certbot with auto-renewal
  • ✅ CI runs tests before deploy; deploy uses set -e and pm2 reload
  • ✅ Secrets in .env on the server and in GitHub Actions secrets, never in git

When to outgrow this#

One EC2 instance is a single point of failure. When you need more, the next steps build on the same pieces: put two instances behind an Application Load Balancer, move to Docker images so every server is identical, or bake an AMI so new instances boot ready to serve. But for a lot of production apps, this setup is reliable, cheap and easy to reason about.