cicd
16 May 2026
8 MIN READ

GitHub Actions CI/CD for NestJS: Deploy to EC2 with Docker (Free)

Most developers deploy side projects manually — SSH in, pull code, restart, hope nothing breaks. Here's how to automate the entire process with GitHub Actions and deploy to EC2 on every push to main.


CI/CD Pipelines for Side Projects: A Step-by-Step Guide

Most developers deploy their side projects manually — SSH into the server, pull the latest code, restart the process, and hope nothing breaks.

That works until it doesn't.

This guide shows you how to build a complete CI/CD pipeline using GitHub Actions that automatically tests, builds, and deploys your NestJS app to AWS EC2 on every push to main. No more manual deployments. No more 2am hotfixes gone wrong.


What We're Building

Developer pushes to main branch
          ↓
GitHub Actions triggers workflow
          ↓
Run tests → Build Docker image → Push to registry
          ↓
SSH into EC2 → Pull new image → Restart container
          ↓
App live with zero downtime ✅

Stack:

  • NestJS + PostgreSQL application
  • Docker for containerization
  • GitHub Actions for CI/CD
  • AWS EC2 for hosting
  • GitHub Container Registry (free) for images

Step 1 — Dockerize Your NestJS App

Before automating deployments, your app needs to run in a container.

# Dockerfile
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

# Production image
FROM node:20-alpine AS production

WORKDIR /app

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json

EXPOSE 3000

CMD ["node", "dist/main.js"]
# docker-compose.yml — for local development
version: '3.8'
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: ${DATABASE_URL}
      JWT_SECRET: ${JWT_SECRET}
      REDIS_HOST: redis
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  postgres_data:

Test it locally first:

docker build -t myapp .
docker run -p 3000:3000 myapp

Step 2 — Set Up EC2 Server

Launch a Ubuntu 22.04 EC2 instance (t2.micro for free tier).

Install Docker on EC2:

# SSH into your EC2
ssh -i your-key.pem ubuntu@your-ec2-ip

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Add ubuntu user to docker group
sudo usermod -aG docker ubuntu

# Install Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

# Verify
docker --version
docker-compose --version

Create deployment directory:

mkdir -p /home/ubuntu/app
cd /home/ubuntu/app

# Create production docker-compose
cat > docker-compose.prod.yml << 'EOF'
version: '3.8'
services:
  app:
    image: ghcr.io/YOUR_GITHUB_USERNAME/YOUR_REPO:latest
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: ${DATABASE_URL}
      JWT_SECRET: ${JWT_SECRET}
      REDIS_HOST: redis
      NODE_ENV: production
    depends_on:
      - redis
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app
    restart: unless-stopped
EOF

Create .env on EC2:

cat > /home/ubuntu/app/.env << 'EOF'
DATABASE_URL=postgresql://user:password@your-db-host:5432/dbname
JWT_SECRET=your-super-secret-jwt-key
REDIS_HOST=redis
NODE_ENV=production
EOF

chmod 600 /home/ubuntu/app/.env

Step 3 — Configure GitHub Secrets

Go to your GitHub repo → Settings → Secrets and variables → Actions → New repository secret.

Add these secrets:

EC2_HOST          → your EC2 public IP (e.g. 13.234.xxx.xxx)
EC2_USERNAME      → ubuntu
EC2_SSH_KEY       → your entire .pem file contents
GHCR_TOKEN        → GitHub personal access token with packages:write scope

Generate GHCR token:

GitHub → Settings → Developer settings
→ Personal access tokens → Tokens (classic)
→ Generate new token
→ Select: write:packages, read:packages, delete:packages
→ Copy the token → paste as GHCR_TOKEN secret

Building something like this? Hire me to build your backend — NestJS, Node.js, PostgreSQL & Redis, shipped production-ready.

Step 4 — Create GitHub Actions Workflow

# .github/workflows/deploy.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ─────────────────────────────────────
  # JOB 1: Run Tests
  # ─────────────────────────────────────
  test:
    name: Run Tests
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run unit tests
        run: npm run test
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
          JWT_SECRET: test-secret

      - name: Run e2e tests
        run: npm run test:e2e
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
          JWT_SECRET: test-secret

  # ─────────────────────────────────────
  # JOB 2: Build & Push Docker Image
  # ─────────────────────────────────────
  build:
    name: Build & Push Image
    runs-on: ubuntu-latest
    needs: test  # only runs if tests pass
    if: github.ref == 'refs/heads/main'  # only on main branch

    permissions:
      contents: read
      packages: write

    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GHCR_TOKEN }}

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ─────────────────────────────────────
  # JOB 3: Deploy to EC2
  # ─────────────────────────────────────
  deploy:
    name: Deploy to EC2
    runs-on: ubuntu-latest
    needs: build  # only runs after image is built
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Deploy to EC2
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USERNAME }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            cd /home/ubuntu/app

            # Login to GitHub Container Registry
            echo ${{ secrets.GHCR_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin

            # Pull latest image
            docker pull ghcr.io/${{ github.repository }}:latest

            # Stop and remove old container
            docker-compose -f docker-compose.prod.yml down

            # Start new container
            docker-compose -f docker-compose.prod.yml up -d

            # Remove unused images to save disk space
            docker image prune -f

            echo "✅ Deployment complete"

Step 5 — Add Nginx Config

# nginx.conf
server {
    listen 80;
    server_name your-domain.com;

    location / {
        proxy_pass http://app: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_cache_bypass $http_upgrade;
    }
}

Step 6 — Database Migrations

Never run migrations inside the app startup. Run them as a separate step in the pipeline.

# Add this step to your deploy job BEFORE starting containers
- name: Run database migrations
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.EC2_HOST }}
    username: ${{ secrets.EC2_USERNAME }}
    key: ${{ secrets.EC2_SSH_KEY }}
    script: |
      cd /home/ubuntu/app

      # Run migrations in a one-off container
      docker run --rm \
        --env-file .env \
        ghcr.io/${{ github.repository }}:latest \
        npx prisma migrate deploy

      echo "✅ Migrations complete"
// main.ts — do NOT run migrations on startup
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // ❌ Never do this in production
  // await prisma.$executeRaw`...migration...`
  await app.listen(3000);
}

Step 7 — Environment-Specific Workflows

For side projects, two environments is enough:

# Staging — deploys on push to develop branch
on:
  push:
    branches:
      - develop    # → staging server
      - main       # → production server

jobs:
  deploy:
    steps:
      - name: Set environment
        run: |
          if [ "${{ github.ref }}" == "refs/heads/main" ]; then
            echo "EC2_HOST=${{ secrets.PROD_EC2_HOST }}" >> $GITHUB_ENV
          else
            echo "EC2_HOST=${{ secrets.STAGING_EC2_HOST }}" >> $GITHUB_ENV
          fi

What the Pipeline Looks Like

After pushing to main, go to your repo → Actions tab:

✅ Run Tests          → 45 seconds
✅ Build & Push Image → 2 minutes 10 seconds
✅ Deploy to EC2      → 35 seconds
─────────────────────────────────────
Total                 → ~3 minutes 30 seconds

Every push to main deploys automatically. Every PR runs tests before merging.


Common Issues & Fixes

Permission denied on EC2:

# On EC2
sudo chmod 666 /var/run/docker.sock
# or add ubuntu to docker group and re-login
sudo usermod -aG docker ubuntu
newgrp docker

Old images filling up disk:

# Add to deploy script
docker system prune -f --volumes

Workflow not triggering:

# Check branch name matches exactly
on:
  push:
    branches: [main]  # not "master"

Secrets not found:

Check: repo Settings → Secrets → Actions
Secrets are case-sensitive — EC2_HOST not ec2_host

Cost Breakdown

Running this entire setup:

ServiceCost
EC2 t2.microFree tier (750hrs/month)
GitHub ActionsFree (2,000 min/month)
GitHub Container RegistryFree (500MB storage)
Total$0/month

For a side project, this is completely free.


What You Now Have

✅ Automated testing on every push
✅ Docker image built and versioned automatically
✅ Zero-touch deployment to EC2
✅ Database migrations run safely before deploy
✅ Nginx reverse proxy with easy SSL setup
✅ Old images cleaned up automatically
✅ Staging + production environments
✅ Full audit trail of every deployment in GitHub

No more SSH + git pull + pm2 restart. Push to main and walk away.


This CI/CD setup is what I use across my freelance projects. Combined with the NestJS + Prisma + PostgreSQL stack and Redis caching, it gives you a production-grade backend that deploys itself.

Need help setting this up for your project? Let's talk.


Have questions about the pipeline or stuck on a specific step? Drop a Message

Mentioned Technologies

#cicd#githubactions#devops#nestjs#nodejs#docker#aws#backend

Frequently Asked Questions

Yes. GitHub Actions has a generous free tier that is enough to run tests, build Docker images, and deploy small-to-medium NestJS apps at no cost.

The workflow builds a Docker image on push, connects to the EC2 instance over SSH, pulls or transfers the image, and restarts the container, automated on every commit.

Docker is not strictly required, but it makes deployments reproducible and avoids environment drift between local, CI, and the EC2 server.

Related Reading

Need this built right?

I'm a freelance NestJS & Node.js backend developer. Let's ship your MVP or SaaS.

Hire Me

Ready for more?

Explore other insights in the gallery.

Browse All Posts