Skip to main content

Cloud & deployment

How to Deploy a NestJS App to AWS EC2 with GitHub Actions (Without Building on the Server)

By Abdur Rehman · Code Huddle · Product engineering guides

The most reliable way to deploy a NestJS API to AWS EC2 is to build it in GitHub Actions, ship a ready-to-run archive to the instance, start it in a new release folder, reload it with PM2, and roll back automatically if a health check fails. This guide walks through that CI/CD pipeline step by step, with production-ready examples and the common mistakes to avoid. Who this is for: teams running a NestJS (Node.js + TypeScript) backend on one or more EC2 instances who want automated, low-risk deployments without moving to Docker or Kubernetes yet.

Why not just git pull && npm run build on the server?

Many EC2 deployments start with SSH-ing in, pulling the latest code and building in place. It works until it doesn't:

  • The build competes with your live app. TypeScript compilation is memory-hungry. On a small instance (t3.small, t3.medium) it can starve the running API or crash with JavaScript heap out of memory.
  • Every server needs build tools, dev dependencies and Git access. That means more to install, more to patch, and more ways to attack it.
  • There is no clean rollback. Once the old build is overwritten, going back means rebuilding an old commit under pressure.

The fix is simple: build once in CI, deploy an artifact.

The deployment architecture

git push ─▶ GitHub Actions ─▶ npm ci, build, prune ─▶ build.tar.gz
                                                          │ scp
                                                          ▼
EC2:  releases/<timestamp>/  ─▶  current (symlink)  ─▶  PM2 reload  ─▶  /health/ready
                                     ▲                                       │
                                     └──────── rollback if unhealthy ◀───────┘

Each deploy lands in its own folder. A current symlink points to the live release. PM2 reloads the app from current, and if the health check fails, the symlink is switched back to the previous release.

Step 1: Prepare the EC2 instance (one time)

Install Node.js (match the version used in CI) and PM2, then create the folder layout:

npm install -g pm2
mkdir -p ~/my-api/releases ~/my-api/shared
# Put production environment variables here, never in Git or the build archive
nano ~/my-api/shared/.env
pm2 startup   # follow the printed command so PM2 restarts on reboot

Put Nginx (or an AWS Application Load Balancer) in front of the app for TLS, and only open ports 80/443 to the public in your security group. Keep the Node.js port private.

Step 2: Make NestJS deployment-friendly

Graceful shutdown. In main.ts, let NestJS close database connections and finish in-flight requests when PM2 stops a process:

app.enableShutdownHooks();
await app.listen(port);
if (process.send) process.send('ready'); // tells PM2 this instance can take traffic

A readiness endpoint. Use @nestjs/terminus to expose /health/ready, which only returns 200 when the app can actually serve traffic:

@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
  ) {}

  @Get('ready')
  @HealthCheck()
  ready() {
    return this.health.check([() => this.db.pingCheck('database')]);
  }
}

Keep a separate /health/live that does not check dependencies. Liveness answers "is the process stuck?"; readiness answers "should this instance receive requests?"

A PM2 ecosystem file (ecosystem.config.js, committed to the repo) so process settings live in code, not in shell history:

module.exports = {
  apps: [
    {
      name: 'my-api',
      cwd: '/home/ubuntu/my-api/current',
      script: 'dist/main.js',
      exec_mode: 'cluster',
      instances: 2,        // 2+ instances lets PM2 reload one at a time
      wait_ready: true,    // wait for process.send('ready')
      listen_timeout: 15000,
      kill_timeout: 10000, // time allowed for graceful shutdown
    },
  ],
};

Step 3: The GitHub Actions workflow

name: Deploy API to EC2

on:
  push:
    branches: [main]

concurrency:
  group: deploy-${{ github.ref_name }}
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install, build and remove dev dependencies
        run: |
          npm ci
          npm run build
          npm prune --omit=dev

      - name: Package release
        run: tar -czf build.tar.gz dist node_modules package.json ecosystem.config.js deploy.sh

      - uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.EC2_SSH_KEY }}

      - name: Trust the EC2 host key
        run: echo "${{ secrets.EC2_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts

      - name: Upload and deploy
        run: |
          scp build.tar.gz ubuntu@${{ secrets.EC2_HOST }}:~/my-api/
          ssh ubuntu@${{ secrets.EC2_HOST }} \
            'tar -xzf ~/my-api/build.tar.gz -C ~/my-api deploy.sh && bash ~/my-api/deploy.sh'

What each part does:

  • concurrency cancels an older run when a newer commit is pushed, so two deployments never overwrite each other.
  • npm ci installs exactly what is in package-lock.json. npm install can quietly resolve different versions.
  • npm prune --omit=dev removes TypeScript, Jest, ESLint and other dev dependencies from the shipped node_modules.
  • EC2_KNOWN_HOSTS is the server's real host key (from ssh-keyscan run once, then verified). Running ssh-keyscan inside the pipeline trusts whichever machine answers.
  • environment: production lets you add required reviewers and keep production secrets separate from staging.

For multiple environments, deploy develop → dev, staging → staging and main → production by mapping github.ref_name to a host and folder, each with its own secrets.

Step 4: The deploy script on EC2

deploy.sh (committed to the repo and shipped inside the archive):

#!/usr/bin/env bash
set -euo pipefail

APP_DIR=/home/ubuntu/my-api
PORT=3000
KEEP_RELEASES=5
RELEASE="$APP_DIR/releases/$(date +%Y%m%d%H%M%S)"

# 1. Unpack the new build into its own folder (the live app is untouched)
mkdir -p "$RELEASE"
tar -xzf "$APP_DIR/build.tar.gz" -C "$RELEASE"
rm -f "$APP_DIR/build.tar.gz"
ln -s "$APP_DIR/shared/.env" "$RELEASE/.env"

# 2. Remember the current release, then switch the symlink atomically
PREVIOUS=$([ -L "$APP_DIR/current" ] && readlink -f "$APP_DIR/current" || echo "")
switch_to() {
  ln -sfn "$1" "$APP_DIR/current.tmp"
  mv -Tf "$APP_DIR/current.tmp" "$APP_DIR/current"
  pm2 startOrReload "$APP_DIR/current/ecosystem.config.js" --update-env
}
switch_to "$RELEASE"

# 3. Health check, then clean up or roll back
for i in $(seq 1 10); do
  if curl -sf "http://127.0.0.1:$PORT/health/ready" > /dev/null; then
    pm2 save
    ls -1dt "$APP_DIR"/releases/* | tail -n +$((KEEP_RELEASES + 1)) | xargs -r rm -rf
    echo "Deployed $RELEASE"
    exit 0
  fi
  sleep 3
done

echo "Health check failed. Rolling back to $PREVIOUS"
[ -n "$PREVIOUS" ] && switch_to "$PREVIOUS"
exit 1

Because the script exits with 1 on failure, the GitHub Actions job turns red, and your team is notified while the previous version keeps serving traffic.

Common NestJS EC2 deployment mistakes

  • pm2 stop → replace files → pm2 start — The API is down for the whole deploy. Better: pm2 reload in cluster mode with 2+ instances.
  • Deleting dist and node_modules before the new build is verified — A bad build leaves nothing to roll back to. Better: release folders and a current symlink.
  • No set -e in deploy scripts — A failed step is ignored and the script keeps going. Better: set -euo pipefail.
  • pm2 start -i max followed by pm2 scale 1 — Briefly starts a process per CPU core, then kills them. Better: set instances once in ecosystem.config.js.
  • Building TypeScript on the EC2 instance — Memory spikes and slow, unpredictable deploys. Better: build in CI, ship dist.
  • Shipping dev dependencies — Larger archive, bigger attack surface. Better: npm prune --omit=dev.
  • .env inside the repo or the build archive — Secrets leak through Git history or CI logs. Better: keep it in shared/.env on the server, or use AWS SSM Parameter Store.
  • No enableShutdownHooks() — In-flight requests and DB connections are cut off on every reload. Better: enable hooks and set PM2 kill_timeout.

When to move beyond this setup

This pipeline is a strong fit for one to a few EC2 instances. Consider the next step when:

  • You want to remove SSH keys from CI: use GitHub OIDC to assume an AWS IAM role, then deploy through AWS Systems Manager (SSM) Run Command or AWS CodeDeploy. Port 22 can then stay closed.
  • You run many instances behind a load balancer: CodeDeploy or an Auto Scaling group with rolling updates handles instance-by-instance rollouts.
  • You need identical environments everywhere: containerize the NestJS app and move to Amazon ECS (Fargate).

FAQ

Can I deploy NestJS to EC2 with zero downtime?

Yes. Run the app in PM2 cluster mode with at least two instances, use pm2 reload instead of restart, and signal readiness with process.send('ready') plus wait_ready: true. PM2 then replaces processes one at a time.

Should I use PM2 or systemd for NestJS on EC2?

Both are reliable. PM2 adds cluster mode, zero-downtime reloads and simple log management out of the box, which is why it is the common choice for Node.js on a plain VM.

How do I roll back a failed NestJS deployment?

Keep each build in its own release folder and point a current symlink at the live one. Rolling back means pointing the symlink at the previous folder and running pm2 reload, which takes seconds instead of a full rebuild.

Is it safe to store an SSH key in GitHub Secrets?

It is common and acceptable for small teams if the key is dedicated to deployments and restricted to one user. For stronger security, replace SSH with GitHub OIDC and AWS SSM so no long-lived credentials are stored in CI.

Continue reading

Is your web app slow? Don’t buy a bigger server yet

By Kashif Abbas KazmiDiagnose a slow web app before upgrading hosting: measure user journeys, database queries, connection pools, payloads, background jobs, and caching.

Software project handover: a checklist for changing development teams

By Haris AhmedPlan a software handover with clear repository access, deployment instructions, data ownership, integration accounts, tests, operating costs, and release responsibilities.

How to compare software development proposals

By Haris AhmedCompare software development estimates using scope, assumptions, integrations, acceptance criteria, ownership, and operating costs—not just the headline price.

How to scope an AI integration project

By Haris AhmedA practical buyer’s guide to AI integration: define the workflow, data permissions, evaluation, operating costs, delivery scope, and handover before commissioning a build.

Planning a bilingual news website and editorial CMS

By Haris AhmedPlan English–Urdu publishing, RTL layouts, editorial permissions, story URLs, media, and distribution, with concrete examples from Code Huddle’s QOM News project.

How to Choose a Tech Stack for Your Web App and the Mistakes to Avoid

By Muhammad SarimLearn how to choose the right tech stack for your web app. Compare frontend, backend and database options, and avoid costly mistakes.

How to Manage Client Requirements Without Losing Control of the Project

By Noor Ul HudaA Project Manager’s guide to clarifying requests, preventing scope creep, documenting decisions, and managing client requirements without losing control of the project.

Who Moderates the Moderation AI?

By Minahil AliCan AI handle content moderation on its own? Learn how moderation models work, how to set thresholds, when humans step in, and the mistakes to avoid.

Testing payment flows: what to check before you launch

By Ateeq AhmadA payment test is not finished when the provider approves a transaction. Check the full lifecycle: failures, retries, refunds, webhooks, and consistent records before launch.

Testing AI-Powered Applications: A QA Engineer’s Practical Guide

By Aymen HameedHow QA can test AI-powered products for accuracy, consistency, uncertainty, end-to-end outcomes, regression coverage, and post-release learning.

The AI-Powered Developer Workflow: From Planning to Code Review and Deployment

By Salis Bin SalmanUse AI across your whole development process without shipping wrong code. A 6-stage workflow for planning, coding, testing, code review and deployment.